Skip to content

fix: honor Spark’s commit protocol in Spark 3.x native writes - #4746

Open
peterxcli wants to merge 13 commits into
apache:mainfrom
peterxcli:feat/spark-file-commit
Open

peterxcli wants to merge 13 commits into
apache:mainfrom
peterxcli:feat/spark-file-commit

Conversation

@peterxcli

@peterxcli peterxcli commented Jun 28, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Addresses the remaining Spark 3.x commit-protocol gaps in #2827 and #3015. This does not claim to close their remaining version and feature gaps.

Rationale for this change

Spark 3.4/3.5 native writes replace the whole DataWritingCommandExec. The retained writer hardcodes the commit protocol, discards the filename returned by newTaskTempFile, and does not consistently complete or abort the job from both execution entry points. Committers that track individual filenames can therefore commit a different file from the one native code writes.

Dependency: #5763 is still open. The writer implementation is based on its current head, 5f5397f5049b1bfc6fe27e6c8e926ea2d0bee959, and the PR remains draft. The branch also includes the existing merge from main at 481aefea9. Functional follow-up changes are limited to the Spark 3.x writer, with focused tests and their CI registrations. Compare the writer implementation against the dependency. Once #5763 merges, this follow-up can be rebased onto main.

What changes are included in this PR?

  • Prepare the Hadoop job with the command's options and fileFormat.prepareWrite, and instantiate Spark's configured FileCommitProtocol. Task contexts inherit the prepared configuration, including changes from setupJob.
  • Pass the exact newTaskTempFile filename through the existing output_path field with work_dir unset. The previous task_output_path proposal is removed; protobuf field 9 remains output_schema. This follow-up changes no protobuf or native Rust code.
  • Return successful task commit messages through runJob, invoke onTaskCommit, and remove the collection accumulator.
  • Run the complete job lifecycle from both row and columnar entry points. Guard filename allocation, native iterator construction, execution, cleanup, and task commit with task abort; guard task execution and job commit with job abort. Close the native iterator before committing, preserving the original error and suppressing secondary cleanup/abort errors.
  • Preserve schema serialization, URI handling and SaveMode behavior. The legacy HDFS admission check now reads the configured basename because Spark's committer chooses filenames. Spark 4.0+ continues through feat: hook native Parquet writes into Spark's WriteFilesExec seam on Spark 4.0+ #5763's unchanged CometWriteFilesExec path.
  • Register CometNativeWriteSuite beside the Parquet writer suite in both Linux and macOS CI matrices. This fixes the Preflight missing-suite failure.

How are these changes tested?

The runtime results below were collected on writer commit 278f28e0a, before the subsequent merge from main and CI registration fix. Built the native library with make core before JVM tests. Maven ran from the repository root, without -pl, using JDK 17 and a debug native build. Spark profiles were built clean when switching versions.

Spark Scala CometNativeWriteSuite CometParquetWriterSuite
3.4.3 2.12.17 8 passed 39 passed, 9 version-gated cancellations
3.5.9 2.12.18 8 passed 41 passed, 7 version-gated cancellations
4.0.4 2.13.16 Not run: Spark 3.x-specific suite 48 passed

The new suite uses a configured committer with an unusual exact filename containing spaces and %, checks job-option/file-format preparation and task-message delivery, and verifies native row metrics and output read-back for both entry points. It injects failures in native iterator construction, native batch execution, final native metrics cleanup, commitTask, onTaskCommit, and commitJob, including throwing abort callbacks. Assertions check task/job abort, preservation of the original exception, suppressed cleanup errors, _SUCCESS, and staging cleanup. Existing success-marker, Spark filename, and task-abort checks now also run on Spark 3.x; the writer suite covers SaveMode, schema/field IDs, codecs, and local URI regressions.

Spotless, Scalastyle, and git diff --check passed.

After the merge from main, CI registration commit e9f5e5e7f passed the suite inventory check, CI configuration checks, all 15 Iceberg shard validation tests, benchmark runner checks, and actionlint --shellcheck=off. A clean Spark 3.5/Scala 2.12/JDK 17 package build and semantic Scalafix check also passed (-DskipTests; runtime tests were not repeated for the two workflow-only additions).

Limitations: validation used local Spark execution, not a real HDFS cluster, automatic task retries, or speculative attempts. #5763's unresolved percent-bearing HDFS basename admission issue remains a dependency limitation. This does not extend the legacy writer's catalog refresh, partitioning, file rolling, or other experimental writer behavior.

@comphead

Copy link
Copy Markdown
Contributor

Thanks @peterxcli please check on your fork https://github.com/apache/datafusion-comet/actions/workflows/spark_sql_writer_tests.yml

@peterxcli
peterxcli marked this pull request as ready for review July 13, 2026 18:24
@peterxcli

Copy link
Copy Markdown
Member Author

@comphead would you like to take a look? Thanks!

@comphead

Copy link
Copy Markdown
Contributor

Thanks @peterxcli there is another angle #5293

@peterxcli peterxcli closed this Aug 22, 2026
…Spark 4.0+

Native writes replace the whole DataWritingCommandExec, which means
InsertIntoHadoopFsRelationCommand.run never runs. Everything that method does
has to be re-implemented inside CometNativeWriteExec: a hardcoded
SQLHadoopMapReduceCommitProtocol (so spark.sql.sources.commitProtocolClass is
ignored), dynamicPartitionOverwrite pinned to false, a hand-ported copy of the
SaveMode logic, a bespoke commit-message accumulator, and its own commitJob
call.

Most of the open native-writer issues are symptoms of that one decision rather
than independent defects. On Spark 4.0+, V1WritesUtils.getWriteFilesOpt matches
the WriteFilesExecBase trait (introduced in 4.0 precisely for this), so a Comet
node that extends it gets driven through FileFormatWriter.executeWrite ->
SparkPlan.executeWrite -> doExecuteWrite, and Spark keeps ownership of
everything above the per-task write.

Spark 3.x has no such trait: getWriteFilesOpt matches the concrete
WriteFilesExec case class, a Comet node there would not be found, and Spark
would silently take FileFormatWriter's non-planned, row-based branch. So the
new seam is additive. CometDataWritingCommand and CometNativeWriteExec are kept
unchanged and remain the 3.4/3.5 path; CometExecRule picks the path by version
and the two never both fire. The legacy path goes away with Spark 3.x support.

Add:

- CometWriteFilesExec, overriding doExecuteWrite and mirroring
  FileFormatWriter.executeTask for the parts Comet must do itself: build the
  TaskAttemptContext, ask the commit protocol for a path, run the native
  writer, drive the stats trackers, commit or abort. Plus the CometWriteFiles
  serde and a two-line ShimCometWriteFilesExec in spark-4.x / spark-3.x.
- File paths come from FileCommitProtocol.newTaskTempFile and are used
  verbatim, so names match Spark's part-<id>-<uuid>-c000.<codec>.parquet and
  committers that track individual files (S3A magic, streaming manifest) work.
- Column names, nullability and field IDs come from
  WriteJobDescription.dataColumns rather than the query output, so
  INSERT INTO t SELECT a+1 writes the target column's name.
- Byte and row counts come from BasicWriteTaskStatsTracker, which stats files
  through the FileSystem API and is therefore correct on HDFS.
- ParquetWriter proto: work_dir is now optional. When set (3.x) the native
  writer derives the file name as before; when unset (4.0+) output_path is the
  exact file to write and is used verbatim.
- On 4.0+ the opt-in moves to spark.comet.operator.WriteFilesExec
  .allowIncompatible, with the old DataWritingCommandExec key kept as a
  deprecated alternative. isOperatorAllowIncompat now resolves alternatives,
  which the planner's by-name lookup previously bypassed.

AQE re-plans the write command's child and re-inserts a WriteFilesExec above the
node Comet already converted; leaving DataWritingCommandExec in place means
Comet no longer has to guard against the resulting nested native writes.
…configs

Matches the reviewed form on apache#5293: ConfigBuilder mutates in place, so the Seq
destructuring was rebinding the same object. Only one operator has an
alternative and there is no reason to expect more.
The output path reaches both write serdes as `Path.toString`, which decodes
percent escapes: a directory containing a space or a literal `%` yields a
string that is not a valid URI, and `URI.create` throws on it.

On Spark 4.0+ that exception escaped `CometWriteFiles.convert` and failed the
query. On 3.x `CometDataWritingCommand.convert` caught it and silently handed
the write back to Spark, so the native writer was never used for those paths.
Round-trip through `Path` instead, which re-escapes. Only the scheme and
authority reach `extractObjectStoreOptions`, but parsing has to succeed to get
at them.

Also assert that the INSERT INTO visibility test's write actually went native,
rather than inferring it from the read-back.
The raw/decoded URI comparison only catches what java.net.URI had to
escape, and java.net.URI leaves non-ASCII path characters alone, so an
hdfs://ns/cafe<U+0301>/output destination was admitted. percent_encoding's
should_percent_encode is !byte.is_ascii() || set.contains(byte), so the
native parser escapes every non-ASCII byte regardless of the encode set
and the writer creates caf%C3%A9 outside Spark's staging directory.

The guard now also declines any character the native parser rewrites. The
ASCII half of that set was determined against the locked url 2.5 crate by
parsing hdfs://ns/pre<c>post/output for every printable ASCII c: space, ",
#, <, >, ?, backtick, { and } are rewritten and the rest survive, so
partition directories and Spark's _temporary attempt paths still qualify.
The comment no longer claims the Java comparison detects the divergence on
its own; both conditions are kept because the Java one still catches a
literal % that the native parser leaves alone.

Tests add accented (precomposed and combining), CJK, emoji and nested
non-ASCII cases plus the remaining escaped ASCII characters, built from
code points since scalastyle forbids non-ASCII source. Disabling the new
condition makes the accented case fail, so the Java comparison alone
demonstrably does not cover it.
@peterxcli peterxcli reopened this Sep 12, 2026
@peterxcli
peterxcli marked this pull request as draft September 12, 2026 17:30
@github-actions github-actions Bot added enhancement New feature or request area:writer Native Parquet writer labels Sep 12, 2026
andygrove and others added 3 commits September 13, 2026 08:48
Correctness:

- Decline HDFS writes whose *file names* would diverge, not just their
  directory. `mapreduce.output.basename` is caller-controlled and reaches every
  committed name through `HadoopMapReduceCommitProtocol.getFilename`; a basename
  holding `?` or `#` makes the native URL parser truncate, so every task writes
  the same name and they overwrite each other at commit. Adds an execution-time
  backstop over the complete `newTaskTempFile` path, which a custom commit
  protocol owns and planning cannot predict.
- Read the compression option case-insensitively, as Spark's `ParquetOptions`
  does. `option("Compression", "lz4_raw")` used to fall through to the SQLConf
  default, so the unsupported-codec guard was bypassed and Comet wrote SNAPPY
  into a file Spark had named `.lz4raw.parquet`. The codec is now also
  re-derived per task from `CodecConfig.from(taskAttemptContext)`, the same
  place the file extension comes from, so the name and the contents agree by
  construction. The shared helpers move to `NativeWriteUtils`, which fixes the
  identical bug on the Spark 3.x path.
- Use `Utils.tryWithSafeFinallyAndFailureCallbacks` / `tryWithSafeFinally` in
  `executeTask` and `writeNatively`, matching `FileFormatWriter`: a failure
  while aborting or closing the iterator is attached as a suppressed exception
  instead of replacing the failure that caused it. `statsTrackers` moves inside
  the guard so a throwing `newTaskInstance` still reaches `abortTask`.

Planning and reporting:

- Convert `WriteFilesExec` from its enclosing `DataWritingCommandExec` rather
  than from a separate tag pre-pass, so the output path comes straight from the
  command that owns it and neither `withNewChildren` copying tags nor "nothing
  hands us a bare WriteFilesExec" has to hold.
- Only skip the fallback reason on `DataWritingCommandExec` when its child
  really was converted; a write with no native child now says why it fell back.
- Drop the node's duplicate `files_written`/`bytes_written`/`rows_written`.
  `BasicWriteJobStatsTracker` is authoritative here, and the native
  `bytes_written` reads 0 on HDFS.

Tests and docs:

- Rust `url_path_rewritten_characters` pins the `url` crate's path encode set,
  which the JVM guard mirrors; a crate upgrade can no longer reopen the hole
  with a green build.
- New JVM coverage: mixed-case `compression` (honored, and declined when
  unsupported), the apache#3426 nested-name INSERT, an empty non-zero partition
  writing no file, the third-party `WriteTaskStatsTracker` warning, and the
  basename/committer-path guards.
- The abort test no longer claims to demonstrate task retry or speculation.
- `installation.md` says which Spark versions its EXPLAIN output applies to.
@peterxcli
peterxcli force-pushed the feat/spark-file-commit branch from 915b174 to 278f28e Compare September 14, 2026 16:21
@peterxcli peterxcli changed the title feat(parquet): implement file commit protocol for native Parquet writes fix: honor Spark’s commit protocol in Spark 3.x native writes Sep 14, 2026
@github-actions github-actions Bot added the area:scan Parquet scan / data reading label Sep 14, 2026
@peterxcli
peterxcli marked this pull request as ready for review September 15, 2026 07:19

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5763 landed on the 19th as 5442c937d, so this branch still carries its pre-merge commits and conflicts with main in six files. Replaying only 278f28e0a and e9f5e5e7f onto main conflicts just in the CometDataWritingCommand imports, where #5821 added the hasEmptyRelationInput guard, and in the two suite lists. Could you rebase down to those two commits? Two comments describe the old 3.x writer and stop being true with this change. NativeWriteUtils.escapedHdfsDestination says that on 3.x Comet names the files itself and the basename is always part, and the ParquetWriterExec field docs say work_dir is the Spark 3.x path. After this nothing sets work_dir, job_id or task_attempt_id, so could the Some(work_dir) arm in ParquetWriterExec::execute go as well, with the three proto fields reserved?

The task closure calls createTaskContext, which is an instance method because it reads jobTrackerID. That pulls the whole CometNativeWriteExec, child subtree included, into every task despite the captured* locals, which is the thing CometWriteFilesExec goes out of its way to avoid. Could createTaskContext move to the companion object and take jobTrackerID as a parameter?

runNativeWriteJob now runs the same task loop as CometWriteFilesExec, but it leaves out the two empty-input cases that one copies from FileFormatWriter. A zero-partition child runs no task at all, so the output gets a _SUCCESS and no schema-bearing file, and spark.read.parquet on it fails to infer a schema (SPARK-23271, #5303). And every empty partition still writes its own file, because create_arrow_writer runs before the first batch. Could this take the same parallelize(Seq.empty[ColumnarBatch], 1) swap and the sparkPartitionId == 0 || batches.hasNext check, and drop the assume(isSpark40Plus) from the SPARK-23271 and empty-partition tests in CometParquetWriterSuite?

apache#5763 landed as 5442c93, so the branch's pre-merge copies of its commits
are superseded by main's squashed version. The tree matches replaying only
278f28e and e9f5e5e onto main, with the CometDataWritingCommand imports
and the two suite lists resolved.
Both native writers now take the exact file name from Spark's commit
protocol, so nothing sets work_dir, job_id or task_attempt_id any more.
Remove the Some(work_dir) arm from ParquetWriterExec::execute, reserve
the three proto fields, and correct the comments that still described
the Spark 3.x writer naming its own files.
Mirror CometWriteFilesExec and FileFormatWriter: swap a zero-partition
child for a single empty partition so the output still carries a schema
(SPARK-23271), and only write a file from partition 0 or a partition
that has rows. Move createTaskContext to the companion object so the
task closure no longer captures the exec node and its child subtree.
The SPARK-23271 and empty-partition tests now run on Spark 3.x too.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Prior state and problem: The legacy writer bypassed Spark’s configured commit protocol, discarded its chosen filename, and handled job completion inconsistently between execution entry points.
  • Design approach: Spark 3.x now runs the configured job/task lifecycle. The included Spark 4 prerequisite replaces WriteFilesExec, leaving job commit, SaveMode handling and catalog refresh with Spark.
  • Correctness / compatibility analysis: Compared the write contracts against Spark 3.4.3, 3.5.9, 4.0.4, 4.1.3 and 4.2.0. Filename ownership, task identifiers, commit-message delivery and exception preservation follow Spark’s contracts. The existing P2 concerning percent-bearing HDFS basenames remains in this checkout. A local probe using the current guard methods and real Hadoop Path confirmed that part%foo and part%25 pass planning but fail the task guard. Ordinary part succeeds. This duplicates an existing finding, so no new inline finding is returned.
  • Key design decisions: Separate version paths are justified by Spark’s concrete-class versus base-trait write discovery. Explicit task state keeps the Spark 4 serialization boundary clear. The existing Spark 3 closure-capture concern remains visible. No measured performance regression was established, and the per-row statistics callback cost remains unmeasured.
  • Implementation sketch: Prepare the Hadoop job, allocate the committer’s exact filename, execute and close the native iterator, then commit the task and deliver its message through runJob. Failures preserve the original exception while invoking abort callbacks.
  • Behavioral changes worth calling out: Both Spark 3 execution entry points now complete job commit, and filenames follow Spark’s convention. Spark 4 retains Spark’s surrounding write framework. Existing Spark 3 empty-input concerns are already covered by the prior review.
  • Suggested improvements: Resolve the existing HDFS basename P2 by applying the Java URI-escaping check to the basename during planning, allowing these writes to fall back before execution.

Reviewed the entire 21-file diff from 481aefea9c60592612650de73bd2e1c7aa173979 to e9f5e5e7fe22baa4d5ff74499a13c7c72aa3beb6, including prerequisites. Confirmed non-draft status and read existing discussion. Routed skill: review-comet-pr. No sibling skill applies to this operator review.

Exact-head CI: 54 successful checks and 10 skipped, with no failures. The inspected CI merge e40331c50bd2f0435798837b835249ec019596ac has the same full tree as the reviewed head. Logs confirm all eight Spark 3.4 commit-lifecycle tests passed, plus 39 writer tests with nine cancellations. Spark 4.0 passed all 48 writer tests.

Local validation: the six-case path probe, suite inventory check and git diff --check passed. No full native/JVM build was run locally. System Maven rejected the repository’s maven.config. No local HDFS integration, automatic retry, speculation or throughput benchmark was run.

No additional introduced P1/P2 issues found within this review. Existing blockers remain.

@peterxcli

Copy link
Copy Markdown
Member Author

Thanks, all addressed:

  • Merged main rather than rebasing, to avoid a force push. The merge commit 032354cb3 has exactly the tree you get by replaying 278f28e0a and e9f5e5e7f onto main, so the diff against main is only this PR's changes.
  • Updated the escapedHdfsDestination and ParquetWriter/ParquetWriterExec docs. Dropped the Some(work_dir) arm and the three fields natively, and reserved them in the proto (f1b5e493c).
  • createTaskContext moved to the companion object and takes jobTrackerID. The task closure only captures locals now.
  • runNativeWriteJob takes the parallelize(Seq.empty[ColumnarBatch], 1) swap and the partitionId == 0 || batches.hasNext check. Removed assume(isSpark40Plus) from the SPARK-23271 and empty-partition tests; both pass on Spark 3.5 (086bc1eef).

With the swap in place, the hasEmptyRelationInput guard from #5821 looks unnecessary on 3.x. Happy to drop it here or in a follow-up.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Prior state and problem: The Spark 3.x writer hardcoded the committer, discarded its allocated filename and handled job completion inconsistently between execution entry points.
  • Design approach: Use Spark’s configured FileCommitProtocol, prepared Hadoop configuration and exact task filename. Return task commit messages through runJob.
  • Correctness / compatibility analysis: Compared lifecycle and filename contracts against Spark 3.4.3 and 3.5.9, and checked the shared native path against supported Spark 4.x sources. Task identifiers, message delivery and cleanup ordering follow those contracts. The earlier HDFS basename, closure-capture and empty-input concerns are addressed in the current tree.
  • Key design decisions: Moving task-context creation to the companion object avoids capturing the execution plan. Removing the accumulator, iterator wrapper and native filename-generation branch simplifies ownership. No introduced performance regression was established. Throughput was not benchmarked.
  • Implementation sketch: Prepare the job, allocate the committer’s filename, execute and close the native iterator, then commit the task and job. Failure paths abort while preserving the original exception.
  • Behavioral changes worth calling out: Both execution entry points complete job commit. Zero-partition input gets a schema-bearing file, and empty nonzero partitions skip file creation. Removed protobuf field numbers and names are reserved.
  • Suggested improvements: None at P1/P2 severity.

Reviewed the entire 10-file diff from a86c9672a63c909f0fd7b752c86b5a679df4e84b to 086bc1eefc49dfa694e210c077984533e8de8450. Confirmed non-draft status and read the existing reviews and discussion. Routed skills: review-comet-pr, review-comet-expression-pr, review-comet-ffi-pr and review-comet-memory-pr.

Exact-head CI: Checks attached to this SHA currently show 20 successful, 13 skipped and two running, with no failures. The running checks are Spark 4.1 execution and expression suites. Inspected logs confirm 49 Parquet writer tests, the empty-relation writer test and four native writer tests passed. All eight Spark 3.x lifecycle tests were canceled by their version gate. CI actually checked out merge 110c2dc6711a86ac089d710eddf9eb23041c19a5, which includes four additional changed files from main.

Validation: An eight-case probe compiled from the current path-guard methods passed using real Hadoop Path, including both previously failing percent-bearing basenames. Suite registration and git diff --check passed. No full local native/JVM build was run. Earlier Spark 3.x runtime evidence predates the latest edits. Spark SQL CI was skipped, and real HDFS, retries, speculation and performance were not validated locally.

No introduced P1/P2 issues found within this review. No substantiated existing P1/P2 blocker remains.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:scan Parquet scan / data reading area:writer Native Parquet writer enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants